App Runner × Aurora DSQL で VPC を意識せずにマルチリージョン構成のアプリケーションを作ってみた #AWSreInvent

App Runner × Aurora DSQL で VPC を意識せずにマルチリージョン構成のアプリケーションを作ってみた #AWSreInvent

先日、Aurora DSQL が発表されました。
リレーショナルデータベースでありながらマルチリージョンで Active/Active の書き込みができるということで、今回の re:Invent でもとびきり注目度の高いアップデートかと思います。

https://dev.classmethod.jp/articles/aurora-amazon-aurora-dsql/

https://dev.classmethod.jp/articles/amazon-aurora-dsql-official-resources/

まだプレビュー公開という形ではありますが、既に us-east-1 など一部リージョンでは利用可能になっています。
Aurora DSQL は VPC を意識しなくて利用可能という特徴があるため、同じく VPC を意識せずに利用可能な App Runner を組み合わせることで可能な限りマネージドな部分を増やしたマルチリージョンアプリケーションを作成してみました!

全体構成

全体構成としては下記になります。

aurora-app-runner-multi-region.png

VPC を全く意識しなくて良いのが良いですね!
us-east-1 の ECR にイメージを push すればアプリケーションのデプロイ作業も完了するので、その点も非常に楽です。
マルチリージョン構成のアプリケーションを作成しようとすると複雑な構成になりがちでしたが、こんなに簡単に作成できて良いのだろうかって思いました。

サンプルアプリケーションについて

検証用に AWS 公式ドキュメント記載のサンプルコードを参考にしつつ、一部変更するサンプルアプリケーションを作成しています。

https://docs.aws.amazon.com/aurora-dsql/latest/userguide/SECTION_program-with-go.html

ソースコード全体を通した変更点は下記になります。

  • 元のコードはリクエストを受けるとデータベースを作成してからデータを投入し、データを取得してから削除するという作りになっています。こちらを /owner への POST リクエストでデータを投入し、/owners パスへの GET リクエストでデータを全件取得する形に変更しました。
  • データベースの作成は事前に CloudShell 経由で行うように変更しました。
  • 接続する Aurora DSQL のホスト名、リージョンを環境変数から取得するように変更しました。
  • どちらのサーバーがリクエストを返しているかを判別するためのカスタムヘッダーを追加しました(こちらは検証の都合上付与しました)。

フォルダ構成は下記です。

% tree .
.
├── Dockerfile
├── app
│   └── main.go
├── go.mod
└── go.sum

go.mod

PostgreSQL ドライバとして pgx、Web フレームワークとして echoを利用しています。
また、Aurora DSQL に接続するトークンを AWS API から取得するために AWS SDK for Go もインストールが必要です。

module myapp

go 1.23.4

require (
	github.com/aws/aws-sdk-go v1.55.5
	github.com/aws/aws-sdk-go-v2 v1.32.6
	github.com/jackc/pgx/v5 v5.7.1
	github.com/labstack/echo/v4 v4.12.0
)

require (
	github.com/aws/smithy-go v1.22.1 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/jackc/pgpassfile v1.0.0 // indirect
	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
	github.com/jackc/puddle/v2 v2.2.2 // indirect
	github.com/jmespath/go-jmespath v0.4.0 // indirect
	github.com/labstack/gommon v0.4.2 // indirect
	github.com/mattn/go-colorable v0.1.13 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/valyala/bytebufferpool v1.0.0 // indirect
	github.com/valyala/fasttemplate v1.2.2 // indirect
	golang.org/x/crypto v0.27.0 // indirect
	golang.org/x/net v0.24.0 // indirect
	golang.org/x/sync v0.8.0 // indirect
	golang.org/x/sys v0.25.0 // indirect
	golang.org/x/text v0.18.0 // indirect
)

go.sum

一応載せましたが、特に語ることは無いです。

github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4=
github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

Dockerfile

Debian の slim とマルチステージビルドを利用して、イメージサイズを低減します。
アプリケーションの作りがシンプル過ぎて参考にならないかもしれませんが、イメージサイズは 56 MB でした。

FROM golang:1.23 AS build-env

# set the working dir.
WORKDIR /app

# copy the go module dependency files.
COPY go.mod go.sum ./

# download the go module dependencies.
RUN go mod download

# copy the source code.
COPY . .

# build the go app binary.
RUN CGO_ENABLED=0 go build -o main ./app/main.go

# set up the container.
FROM debian:12-slim

# set the working dir.
WORKDIR /app

# install the ca-certificates.
RUN apt update && apt install -y ca-certificates

# copy the built app binary from the build-env.
COPY --from=build-env /app/main ./main

# expose the port.
EXPOSE 1323

# command to run the app.
CMD ["./main"]

app/main.go

GetOwners が Aurora DSQL からデータを fetch して全件表示する関数、AddOwner が JSON 形式で受け取ったデータを Aurora DSQL に格納する関数です。
GenerateDbConnectAdminAuthToken が AWS の権限を取得して DB 接続情報を取得する関数、getConnection が取得した接続情報を利用して Aurora DSQL へのコネクションを張る関数です。
GenerateDbConnectAdminAuthToken 関数は環境変数次第で us-east-1 以外へも接続できるようにしたのみで、他は公式ドキュメント記載の物を踏襲しています。
コネクションを張る getConnection 関数は公式ドキュメント記載のサンプルコードをそのまま流用している形です。

package main

import (
	"net/http"
	"fmt"
	"os"
	"context"
	"time"
	"strings"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	_ "github.com/jackc/pgx/v5/stdlib"
	_ "github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go/aws/credentials"
	"github.com/aws/aws-sdk-go/aws/session"
	v4 "github.com/aws/aws-sdk-go/aws/signer/v4"

	"github.com/labstack/echo/v4"
)

type Owner struct {
	Id        string `json:"id"`
	Name      string `json:"name"`
	City      string `json:"city"`
	Telephone string `json:"telephone"`
}

func main() {
	e := echo.New()
	e.Use(AddCustomHeader)
	e.GET("/", func(c echo.Context) error {
		return c.String(http.StatusOK, "Server is Running!!")
	})
	e.GET("/owners", func(c echo.Context) error {
		cluster_endpoint := os.Getenv("CLUSTER_ENDPOINT")
		owners,err := GetOwners(cluster_endpoint)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Unable to retrieve data: %v\n", err)
			os.Exit(1)
		}
		return c.JSON(http.StatusOK, owners)
	})
	e.POST("/owner", func(c echo.Context) error {
		cluster_endpoint := os.Getenv("CLUSTER_ENDPOINT")
		err := AddOwner(cluster_endpoint, c)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Unable to add data: %v\n", err)
			os.Exit(1)
		}
		return c.String(http.StatusOK, "Owner added successfully!")
	})
	e.Logger.Fatal(e.Start(":1323"))
}

func AddCustomHeader(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
			region := os.Getenv("REGION")
			c.Response().Header().Set("X-Custom-Header", region)
			return next(c)
	}
}

func GetOwners(clusterEndpoint string) ([]Owner, error) {
	ctx := context.Background()

	// Establish connection
	conn, err := getConnection(ctx, clusterEndpoint)
	if err != nil {
		return nil, err
	}

	// Fetch data
	query := `SELECT id, name, city, telephone FROM owner`
	rows, err := conn.Query(ctx, query)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	// Collect the results
	owners, err := pgx.CollectRows(rows, pgx.RowToStructByName[Owner])
	if err != nil {
		return nil, err
	}

	// Close connection
	defer conn.Close(ctx)

	return owners, nil
}

func AddOwner(clusterEndpoint string, c echo.Context) error {
  var data Owner
	if err := c.Bind(&data); err != nil {
		return err
	}

	uuid := uuid.New().String()
	ownerData := &Owner{
		Id:        uuid,
		Name:      data.Name,
		City:      data.City,
		Telephone: data.Telephone,
	}

	ctx := context.Background()

	// Establish connection
	conn, err := getConnection(ctx, clusterEndpoint)
	if err != nil {
		return err
	}

	// Insert data
	query := `INSERT INTO owner (id, name, city, telephone) VALUES ($1, $2, $3, $4)`
	_, err = conn.Exec(ctx, query, ownerData.Id, ownerData.Name, ownerData.City, ownerData.Telephone)
	if err != nil {
		return err
	}

	// Close connection
	defer conn.Close(ctx)

	return nil
}

func GenerateDbConnectAdminAuthToken(creds *credentials.Credentials, clusterEndpoint string) (string, error) {
	// the scheme is arbitrary and is only needed because validation of the URL requires one.
	endpoint := "https://" + clusterEndpoint
	req, err := http.NewRequest("GET", endpoint, nil)
	if err != nil {
		return "", err
	}
	values := req.URL.Query()
	values.Set("Action", "DbConnectAdmin")
	req.URL.RawQuery = values.Encode()

	signer := v4.Signer{
		Credentials: creds,
	}
	region := os.Getenv("REGION")
	_, err = signer.Presign(req, nil, "dsql", region, 15*time.Minute, time.Now())
	if err != nil {
		return "", err
	}

	url := req.URL.String()[len("https://"):]

	return url, nil
}

func getConnection(ctx context.Context, clusterEndpoint string) (*pgx.Conn, error) {
	// Build connection URL
	var sb strings.Builder
	sb.WriteString("postgres://")
	sb.WriteString(clusterEndpoint)
	sb.WriteString(":5432/postgres?user=admin&sslmode=verify-full")
	url := sb.String()

	sess, err := session.NewSession()
	if err != nil {
		return nil, err
	}

	creds, err := sess.Config.Credentials.Get()
	if err != nil {
		return nil, err
	}
	staticCredentials := credentials.NewStaticCredentials(
		creds.AccessKeyID,
		creds.SecretAccessKey,
		creds.SessionToken,
	)

	// The token expiration time is optional, and the default value 900 seconds
	// If you are not connecting as admin, use DbConnect action instead
	token, err := GenerateDbConnectAdminAuthToken(staticCredentials, clusterEndpoint)
	if err != nil {
		return nil, err
	}

	connConfig, err := pgx.ParseConfig(url)
	// To avoid issues with parse config set the password directly in config
	connConfig.Password = token
	if err != nil {
		fmt.Fprintf(os.Stderr, "Unable to parse config: %v\n", err)
		os.Exit(1)
	}

	conn, err := pgx.ConnectConfig(ctx, connConfig)

	return conn, err
}

イメージ作成と ECR への push

ECR へのログインから行っていきます。

export AWS_ACCOUNT_ID="xxxxxxxxxxxx"
export REGION="us-east-1"
aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com

イメージをビルドします。

export IMAGE_NAME="echo-app"
export REGISTRY_NAME=$AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com
docker build \
--platform=linux/x86_64 \
 -t $IMAGE_NAME \
 -f Dockerfile --no-cache .

タグを付け直します。

docker tag echo-app:latest $AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/echo-app:latest

イメージを ECR へ push します。

docker push $AWS_ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/echo-app:latest

Aurora DSQL 作成およびテーブル作成

us-east-1 で Aurora DSQL を作成します。

app-runner-aurora-multi-region1.png

設定項目はかなり少ないですね。
現時点では Linked リージョンとして us-east-2、Witness リージョンとして us-west-2 しか選択できなかったので、名前と削除保護くらいです。

作成が完了したので、CloudShell から接続します。
Aurora DSQL は VPC の外に存在するので、VPC 内に CloudShell を起動する必要はありません。
CloudShell 環境に psql コマンドがプリインストールされていたのでそちらを利用します。

$ psql --version
psql (PostgreSQL) 15.8

ホスト名やパスワードなど、接続に必要な情報は画面右上の Connect から取得します。

スクリーンショット 2024-12-08 13.56.13.png

スクリーンショット 2024-12-07 16.24.16.png

Aurora DSQL は SSL 接続を強制するため、PGSSLMODE=requireと指定します。

PGSSLMODE=require \
  psql --dbname postgres \
  --username admin \
  --host xxxxxxxxxxxxxxxxx.us-east-1.on.aws

こちらの指定が無い場合は下記エラーになりました。

psql: error: connection to server at "xxxxxxxxxxxxxxxxx.dsql.us-east-1.on.aws" (xx.xx.xx.xx), port 5432 failed: FATAL:  unable to accept connection, access denied
DETAIL:  Session Id: xxxxxxxxxxxxxxxxx
connection to server at "xxxxxxxxxxxxxxxxx.dsql.us-east-1.on.aws" (xx.xx.xx.xx), port 5432 failed: FATAL:  unable to accept connection, SSL is mandatory. AWS Authentication is required.
DETAIL:  Session Id: xxxxxxxxxxxxxxxxx

パブリックに DB アクセスできることを考えると当然の仕様ですね。
データモデルはサンプルとして記載してあるものをそのまま流用します。

CREATE TABLE IF NOT EXISTS owner (
	id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
	name VARCHAR(255),
	city VARCHAR(255),
	telephone VARCHAR(255)
)

あらかじめデータを一つ突っ込んでおきます。

postgres=> INSERT INTO owner (name, city, telephone) VALUES ('John Doe', 'Anytown', '555-555-0150');
INSERT 0 1

App Runner 作成

App Runner サービスを作成します。

スクリーンショット 2024-12-06 22.23.08.png

スクリーンショット 2024-12-06 22.24.00.png

AWS マネージドポリシーである AmazonAuroraDSQLFullAccess を付与した IAM ロールを作成して、指定します。
DeleteCluster などが付与されているのでもう少し絞れそうですが、一旦置いておきます。

同様に us-east-2 にも App Runner サービスを作成します。
App Runner はクロスリージョンの ECR を指定できるので、イメージは us-east-1 の ECR を選択します。
リージョンごとに Aurora DSQL の接続エンドポイントが存在するので、そちらを環境変数に設定しておきます。
また、カスタムドメインを Route53 として指定すると自動でシンプルレコードとして登録されるので、「Non-Amazon」を選びつつ、手動設定を行います。

スクリーンショット 2024-12-07 16.42.31.png

Route53 レコード作成

今回はヘルスチェックを有効化したレイテンシーベースルーティングのレコードを 2 つ作成します。

スクリーンショット 2024-12-07 23.42.21.png

スクリーンショット 2024-12-07 23.42.31.png

また、ヘルスチェックを作成する際はリクエスト間隔を「高速」にしておきます。
こちらを行うことで、障害発生時や復旧時の検知が早くなります。

スクリーンショット 2024-12-07 22.26.16.png

動作確認

では動作確認をしていきましょう!
まず、us-east-1 に CloudShell を起動して、そこからリクエストを送ります。

[cloudshell-user@ip-10-134-47-213 ~]$ curl -i https://www.masukawa.classmethod.info/owners
HTTP/1.1 200 OK
content-length: 110
content-type: application/json
date: Sun, 08 Dec 2024 04:46:13 GMT
x-custom-header: us-east-1
x-envoy-upstream-service-time: 369
server: envoy

[{"id":"d4795d51-eb74-4feb-9ba3-7c910a1171d8","name":"John Doe","city":"Anytown","telephone":"555-555-0150"}]

us-east-1 側の App Runner からリクエストが返ってきていますね!
us-east-2 の CloudShell からリクエストを送ると、us-east-2 側の App Runner からレスポンスが返ってきていることも確認できます。

[cloudshell-user@ip-10-134-89-13 ~]$ curl -i https://www.masukawa.classmethod.info/owners
HTTP/1.1 200 OK
content-length: 110
content-type: application/json
date: Sun, 08 Dec 2024 04:46:34 GMT
x-custom-header: us-east-2
x-envoy-upstream-service-time: 292
server: envoy

[{"id":"d4795d51-eb74-4feb-9ba3-7c910a1171d8","name":"John Doe","city":"Anytown","telephone":"555-555-0150"}]

us-east-1 側からデータを挿入してみます。

[cloudshell-user@ip-10-134-47-213 ~]$ curl -i -X POST -H "Content-Type: application/json" -d '{"Name" : "Taro" , "City" : "Tokyo", "Telephone": "555-555-0151"}' https://www.masukawa.classmethod.info/owner
HTTP/1.1 200 OK
content-length: 25
content-type: text/plain; charset=UTF-8
date: Sun, 08 Dec 2024 04:47:14 GMT
x-custom-header: us-east-1
x-envoy-upstream-service-time: 389
server: envoy

試しに us-east-2 側からデータ取得を行うと、us-east-1 側から挿入したデータを含めて取得できました。

[cloudshell-user@ip-10-134-89-13 ~]$ curl -i https://www.masukawa.classmethod.info/owners
HTTP/1.1 200 OK
content-length: 212
content-type: application/json
date: Sun, 08 Dec 2024 04:47:40 GMT
x-custom-header: us-east-2
x-envoy-upstream-service-time: 263
server: envoy
connection: close

[{"id":"14df8ef8-c232-45e3-a726-6bcbaa8992a7","name":"Taro","city":"Tokyo","telephone":"555-555-0151"},{"id":"d4795d51-eb74-4feb-9ba3-7c910a1171d8","name":"John Doe","city":"Anytown","telephone":"555-555-0150"}]

ここまではどちらのリージョンもヘルスチェックが通っているので、レイテンシーの小さい方がレスポンスを返しています。
障害を模擬するため、us-east-1 側の App Runner に全ての通信をブロックする WAF をアタッチします。

スクリーンショット 2024-12-07 21.40.32.png

しばらくして us-east-1 の CloudShell からリクエストを送ると、us-east-2 側の App Runner からレスポンスが返ってきていますね!
ヘルスチェックが通らなくなったので、レイテンシー関係なく正常な方からレスポンスが返ってくるようになった形です。

[cloudshell-user@ip-10-134-47-213 ~]$ curl -i https://www.masukawa.classmethod.info/owners
HTTP/1.1 200 OK
content-length: 212
content-type: application/json
date: Sun, 08 Dec 2024 04:50:27 GMT
x-custom-header: us-east-2
x-envoy-upstream-service-time: 272
server: envoy

[{"id":"14df8ef8-c232-45e3-a726-6bcbaa8992a7","name":"Taro","city":"Tokyo","telephone":"555-555-0151"},{"id":"d4795d51-eb74-4feb-9ba3-7c910a1171d8","name":"John Doe","city":"Anytown","telephone":"555-555-0150"}]

この状態で us-east-1 側からデータも挿入しようとすると、us-east-2 側から書き込めています。

[cloudshell-user@ip-10-134-47-213 ~]$ curl -i -X POST -H "Content-Type: application/json" -d '{"Name" : "Hanako" , "City" : "Tokyo", "Telephone": "555-555-0152"}' https://www.masukawa.classmethod.info/owner
HTTP/1.1 200 OK
content-length: 25
content-type: text/plain; charset=UTF-8
date: Sun, 08 Dec 2024 04:51:02 GMT
x-custom-header: us-east-2
x-envoy-upstream-service-time: 454
server: envoy
connection: close

Owner added successfully!

復旧させます(WAF のデフォルトアクションを Allow に変更しました)。
1 分程度経って再度アクセスすると、障害を起こしていた間のデータを含んだ状態で、us-east-1 から取得できています。

[cloudshell-user@ip-10-134-47-213 ~]$ curl -i https://www.masukawa.classmethod.info/owners
HTTP/1.1 200 OK
content-length: 316
content-type: application/json
date: Sun, 08 Dec 2024 04:53:19 GMT
x-custom-header: us-east-1
x-envoy-upstream-service-time: 424
server: envoy

[{"id":"14df8ef8-c232-45e3-a726-6bcbaa8992a7","name":"Taro","city":"Tokyo","telephone":"555-555-0151"},{"id":"9de24310-d0b1-4961-bdf4-27c7e1617956","name":"Hanako","city":"Tokyo","telephone":"555-555-0152"},{"id":"d4795d51-eb74-4feb-9ba3-7c910a1171d8","name":"John Doe","city":"Anytown","telephone":"555-555-0150"}]

まとめ

大部分をマネージドサービスに任せながら、サクッとマルチリージョンアプリケーションを作成できました!
ユーザーとしてはシンプルに設計できているのに、AWS のマネージドなサービスのおかげで高度なことを実現できているというのがすごく良いなと思いました。
コンテナアプリケーションとリレーショナルデータベースという、多くの人にとって取っつき辛さが無さそうな技術を採用できることも嬉しいです。
今後マルチリージョンアプリケーションを作成する際は、選択肢として必ず入ってくるだろうなと感じました。
本格的にプロダクション利用できるタイミングは先になるかもしれませんが、楽しみに待ちたいと思います。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.